summaryrefslogtreecommitdiff
path: root/app/api/auth/[...nextauth]/saml/utils.ts
diff options
context:
space:
mode:
Diffstat (limited to 'app/api/auth/[...nextauth]/saml/utils.ts')
-rw-r--r--app/api/auth/[...nextauth]/saml/utils.ts405
1 files changed, 405 insertions, 0 deletions
diff --git a/app/api/auth/[...nextauth]/saml/utils.ts b/app/api/auth/[...nextauth]/saml/utils.ts
new file mode 100644
index 00000000..7dfe9581
--- /dev/null
+++ b/app/api/auth/[...nextauth]/saml/utils.ts
@@ -0,0 +1,405 @@
+import { SAML, ValidateInResponseTo } from "@node-saml/node-saml";
+import {
+ getIDPMetadata,
+ normalizeCertificate,
+} from "@/lib/saml/idp-metadata";
+import {
+ getSPMetadata,
+} from "@/lib/saml/sp-metadata";
+
+export interface SAMLProfile {
+ nameID?: string;
+ nameIDFormat?: string;
+ attributes?: Record<string, string[]>;
+ [key: string]: unknown;
+}
+
+export interface SAMLUser {
+ id: string;
+ email: string;
+ name: string;
+ companyId?: number;
+ techCompanyId?: number;
+ domain?: string;
+}
+
+// SAML 설정 생성 (sync 함수) - 환경변수 기반으로 변경했음
+export function createSAMLConfig() {
+ console.log("⚙️ Creating SAML configuration...");
+
+ try {
+ const idpMetadata = getIDPMetadata();
+ const spMetadata = getSPMetadata();
+
+ console.log("📋 IdP Metadata loaded:", {
+ entityId: idpMetadata.entityId,
+ ssoUrl: idpMetadata.ssoUrl,
+ organization: idpMetadata.organization,
+ wantAuthnRequestsSigned: idpMetadata.wantAuthnRequestsSigned,
+ });
+
+ console.log("📋 SP Metadata loaded:", {
+ entityId: spMetadata.entityId,
+ callbackUrl: spMetadata.callbackUrl,
+ authnRequestsSigned: spMetadata.authnRequestsSigned,
+ });
+
+ const config = {
+ callbackUrl: spMetadata.callbackUrl,
+ // IDP 메타데이터 기반 설정
+ entryPoint: idpMetadata.ssoUrl,
+ // SP Entity ID
+ issuer: spMetadata.entityId,
+ // IDP 인증서 (정규화된 PEM 형식)
+ idpCert: normalizeCertificate(idpMetadata.certificate),
+ privateKey: process.env.SAML_SP_PRIVATE_KEY,
+ // IdP에서 요구하는 설정
+ identifierFormat: idpMetadata.nameIdFormat,
+ signatureAlgorithm: "sha256" as const,
+ digestAlgorithm: "sha256",
+ // SP 메타데이터 설정
+ decryptionPvk: process.env.SAML_SP_PRIVATE_KEY,
+ publicCert: process.env.SAML_SP_CERT,
+ // IdP 메타데이터 기반 설정
+ wantAuthnResponseSigned: idpMetadata.wantAuthnRequestsSigned,
+ wantAssertionsSigned: spMetadata.wantAssertionsSigned,
+ validateInResponseTo: ValidateInResponseTo.never,
+ disableRequestedAuthnContext: true,
+ // HTTP-Redirect 바인딩 설정
+ authnRequestBinding: undefined, // HTTP-Redirect (GET) 사용 (기본값)
+ skipRequestCompression: false, // Deflate 압축 사용
+ // 추가 보안 설정
+ acceptedClockSkewMs: 5000, // 5초 클럭 차이 허용
+ forceAuthn: false,
+ // IDP Entity ID 설정
+ idpIssuer: idpMetadata.entityId,
+ };
+
+ console.log("✅ SAML Config created:", {
+ callbackUrl: config.callbackUrl,
+ entryPoint: config.entryPoint,
+ issuer: config.issuer,
+ idpIssuer: config.idpIssuer,
+ identifierFormat: config.identifierFormat,
+ hasIdpCert: !!config.idpCert,
+ hasPrivateKey: !!config.privateKey,
+ hasPublicCert: !!config.publicCert,
+ wantAuthnResponseSigned: config.wantAuthnResponseSigned,
+ wantAssertionsSigned: config.wantAssertionsSigned,
+ });
+
+ return config;
+ } catch (error) {
+ console.error("💥 Failed to create SAML Config:", error);
+ throw error;
+ }
+}
+
+// SAML AuthnRequest 생성 (서버 액션)
+export async function createAuthnRequest(): Promise<string> {
+ "use server";
+
+ console.log("SSO STEP 2: Create AuthnRequest");
+
+ try {
+ const config = createSAMLConfig();
+ console.log("SAML Config ready for AuthnRequest generation");
+
+ const saml = new SAML(config);
+ console.log("SAML instance created, generating authorize URL...");
+
+ const startTime = Date.now();
+ const authorizeUrl = await saml.getAuthorizeUrlAsync(
+ "", // RelayState
+ undefined, // host
+ {
+ additionalParams: {},
+ // additionalAuthorizeParams: {},
+ }
+ );
+ const endTime = Date.now();
+
+ // 🔍 SAML AuthnRequest 디코딩 및 분석
+ try {
+ const urlObj = new URL(authorizeUrl);
+ const samlRequest = urlObj.searchParams.get("SAMLRequest");
+
+ if (samlRequest) {
+ console.log("SAML AuthnRequest 분석:");
+ console.log("1️⃣ 원본 URL:", authorizeUrl);
+ console.log(
+ "2️⃣ URL 디코딩된 SAMLRequest:",
+ decodeURIComponent(samlRequest)
+ );
+
+ try {
+ // Base64 디코딩
+ const base64DecodedBuffer = Buffer.from(
+ decodeURIComponent(samlRequest),
+ "base64"
+ );
+ const base64DecodedString = base64DecodedBuffer.toString("utf-8");
+
+ // XML인지 확인 (XML은 '<'로 시작함)
+ if (base64DecodedString.trim().startsWith("<")) {
+ console.log("Base64 디코딩된 XML (압축 없음):");
+ console.log("───────────────────────────────────");
+ console.log(base64DecodedString);
+ console.log("───────────────────────────────────");
+
+ // XML 구조 분석
+ const xmlLines = base64DecodedString
+ .split("\n")
+ .filter((line) => line.trim());
+ console.log("XML 구조 요약:");
+ xmlLines.forEach((line, index) => {
+ const trimmed = line.trim();
+ if (
+ trimmed.includes("<saml") ||
+ trimmed.includes("<samlp") ||
+ trimmed.includes("ID=") ||
+ trimmed.includes("Destination=")
+ ) {
+ console.log(` ${index + 1}: ${trimmed}`);
+ }
+ });
+ } else {
+ // XML이 아니면 Deflate 압축된 것으로 간주
+ console.log(
+ "3️⃣ 압축된 바이너리 데이터 감지, Deflate 압축 해제 시도..."
+ );
+
+ try {
+ const zlib = require("zlib");
+ const decompressed = zlib
+ .inflateRawSync(base64DecodedBuffer)
+ .toString("utf-8");
+ console.log("Deflate 압축 해제된 XML:");
+ console.log("───────────────────────────────────");
+ console.log(decompressed);
+ console.log("───────────────────────────────────");
+
+ // XML 구조 분석
+ const xmlLines = decompressed
+ .split("\n")
+ .filter((line) => line.trim());
+ console.log("XML 구조 요약:");
+ xmlLines.forEach((line, index) => {
+ const trimmed = line.trim();
+ if (
+ trimmed.includes("<saml") ||
+ trimmed.includes("<samlp") ||
+ trimmed.includes("ID=") ||
+ trimmed.includes("Destination=") ||
+ trimmed.includes("Issuer>") ||
+ trimmed.includes("AssertionConsumerServiceURL=")
+ ) {
+ console.log(` ${index + 1}: ${trimmed}`);
+ }
+ });
+
+ // 중요한 정보 추출
+ const idMatch = decompressed.match(/ID="([^"]+)"/);
+ const destinationMatch = decompressed.match(
+ /Destination="([^"]+)"/
+ );
+ const issuerMatch = decompressed.match(
+ /<saml:Issuer[^>]*>([^<]+)<\/saml:Issuer>/
+ );
+ const acsMatch = decompressed.match(
+ /AssertionConsumerServiceURL="([^"]+)"/
+ );
+
+ console.log("추출된 핵심 정보:");
+ console.log(` Request ID: ${idMatch ? idMatch[1] : "없음"}`);
+ console.log(
+ ` Destination: ${
+ destinationMatch ? destinationMatch[1] : "없음"
+ }`
+ );
+ console.log(
+ ` Issuer: ${issuerMatch ? issuerMatch[1] : "없음"}`
+ );
+ console.log(
+ ` Callback URL: ${acsMatch ? acsMatch[1] : "없음"}`
+ );
+ } catch (inflateError) {
+ console.log("❌ Deflate 압축 해제 실패:", inflateError.message);
+ console.log(
+ " 원본 바이너리 데이터 (hex):",
+ base64DecodedBuffer.toString("hex").substring(0, 100) + "..."
+ );
+ }
+ }
+ } catch (decodeError) {
+ console.log("❌ Base64 디코딩 실패:", decodeError.message);
+ }
+ }
+ } catch (analysisError) {
+ console.log("⚠️ SAML AuthnRequest 분석 중 오류:", analysisError.message);
+ }
+
+ console.log("✅ SAML AuthnRequest URL generated:", {
+ url: authorizeUrl.substring(0, 100) + "...",
+ fullUrlLength: authorizeUrl.length,
+ processingTime: `${endTime - startTime}ms`,
+ timestamp: new Date().toISOString(),
+ });
+
+ return authorizeUrl;
+ } catch (error) {
+ console.error("💥 Failed to create SAML AuthnRequest:", {
+ error: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ timestamp: new Date().toISOString(),
+ });
+ throw error;
+ }
+}
+
+// SAML Response 검증 및 파싱 (서버 액션)
+export async function validateSAMLResponse(
+ samlResponse: string
+): Promise<SAMLProfile> {
+ "use server";
+
+ console.log("🔍 Starting SAML Response validation...");
+ console.log("📊 SAML Response info:", {
+ responseLength: samlResponse.length,
+ firstChars: samlResponse.substring(0, 50) + "...",
+ isBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(samlResponse),
+ timestamp: new Date().toISOString(),
+ });
+
+ // 실제 SAML 검증 수행 (기본값)
+ console.log(
+ "🔐 Using Real SAML validation (SAML_USE_MOCKUP=false or not set)"
+ );
+
+ try {
+ console.log("⚙️ Creating SAML instance for validation...");
+ const saml = new SAML(createSAMLConfig());
+ console.log("✅ SAML instance created, starting validation...");
+
+ const startTime = Date.now();
+ const result = await saml.validatePostResponseAsync({
+ SAMLResponse: samlResponse,
+ });
+ const endTime = Date.now();
+
+ // node-saml 라이브러리는 { profile, loggedOut } 형태로 반환
+ const profile = result.profile;
+ if (!profile) {
+ throw new Error("No profile returned from SAML validation");
+ }
+
+ // SAMLProfile 형태로 변환
+ const samlProfile: SAMLProfile = {
+ nameID: profile.nameID,
+ nameIDFormat: profile.nameIDFormat,
+ attributes: profile.attributes || {},
+ };
+
+ console.log("✅ Real SAML Profile validated successfully:", {
+ nameID: samlProfile.nameID,
+ nameIDFormat: samlProfile.nameIDFormat,
+ attributeCount: Object.keys(samlProfile.attributes || {}).length,
+ attributes: Object.keys(samlProfile.attributes || {}),
+ processingTime: `${endTime - startTime}ms`,
+ timestamp: new Date().toISOString(),
+ });
+
+ return samlProfile;
+ } catch (error) {
+ console.error("❌ Real SAML validation error:", {
+ error: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ samlResponseLength: samlResponse.length,
+ timestamp: new Date().toISOString(),
+ });
+ throw new Error(
+ `SAML validation failed: ${
+ error instanceof Error ? error.message : "Unknown error"
+ }`
+ );
+ }
+}
+
+// SAML Profile을 User 객체로 변환 (sync 함수)
+export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser {
+ console.log("🔄 Mapping SAML profile to user:", {
+ nameID: profile.nameID,
+ attributes: profile.attributes,
+ });
+
+ // 기본적으로 nameID를 사용하거나 attributes에서 추출
+ const id =
+ profile.nameID ||
+ profile.attributes?.uid?.[0] ||
+ profile.attributes?.employeeNumber?.[0] ||
+ "";
+ const email =
+ profile.attributes?.email?.[0] ||
+ profile.attributes?.mail?.[0] ||
+ profile.nameID ||
+ "";
+ // UTF-8 이름 처리 개선
+ let name =
+ profile.attributes?.displayName?.[0] ||
+ profile.attributes?.cn?.[0] ||
+ profile.attributes?.name?.[0] ||
+ (profile.attributes?.givenName?.[0] && profile.attributes?.sn?.[0]
+ ? profile.attributes.givenName[0] + " " + profile.attributes.sn[0]
+ : "") ||
+ "";
+
+ // UTF-8 문자열 정규화 및 검증
+ if (name && typeof name === "string") {
+ name = name.normalize("NFC").trim();
+
+ // 한글이 깨진 경우 감지 및 로그
+ const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(name);
+ if (hasInvalidChars) {
+ console.warn("⚠️ Invalid UTF-8 characters detected in name:", {
+ originalName: name,
+ charCodes: [...name].map((c) => c.charCodeAt(0)),
+ hexDump: [...name]
+ .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0"))
+ .join(""),
+ });
+ }
+ }
+
+ // 회사 정보는 SSO 로그인 시 없음
+ const companyId = undefined;
+ const techCompanyId = undefined;
+ const domain = 'evcp';
+
+ const user = {
+ id,
+ email,
+ name: name.trim(),
+ companyId,
+ techCompanyId,
+ domain,
+ };
+
+ console.log("👤 Mapped user object:", user);
+
+ return user;
+}
+
+// SAML 로그아웃 URL 생성 (서버 액션)
+// 로그아웃 지원 안함. 일단 구조만 유사하게 작성해둠.
+export async function createLogoutRequest(nameID: string): Promise<string> {
+ "use server";
+
+ const saml = new SAML(createSAMLConfig());
+ return await saml.getLogoutUrlAsync(
+ nameID,
+ "", // RelayState
+ {
+ nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ }
+ );
+}